Make Perception warning suppression opt-in - #393
Merged
johnnewman-square merged 4 commits intoJul 16, 2026
Conversation
johnnewman-square
marked this pull request as ready for review
July 16, 2026 21:46
amorde
approved these changes
Jul 16, 2026
johnnewman-square
force-pushed
the
johnnewman/fix/restore-ios17-perception-warning
branch
from
July 16, 2026 22:35
b0efb02 to
8b6c0b9
Compare
johnnewman-square
enabled auto-merge (squash)
July 16, 2026 22:44
johnnewman-square
deleted the
johnnewman/fix/restore-ios17-perception-warning
branch
July 16, 2026 22:50
10 tasks
johnnewman-square
added a commit
that referenced
this pull request
Aug 31, 2026
_This description was generated with an agent._ #393 made Perception warning suppression opt-in through `Runtime.Configuration`, which is the right default — but that opt-in is designed to be set once at app startup, and a SwiftUI preview has no equivalent entry point. The canvas instantiates a view directly: no app delegate, no runtime to configure. A preview cannot opt in for itself either, because `_PerceptionLocals` are task-locals and a binding placed around a `#Preview` body-producing closure has gone out of scope by the time SwiftUI evaluates that body. The result is that a preview of an `ObservableScreen` on iOS 17+ reports the warning with no way to act on it. The workarounds available to a consumer are per-preview boilerplate, or reintroducing `WithPerceptionTracking` in production view bodies that no longer need it. This suppresses the check whenever the process is rendering previews, which Xcode signals through `XCODE_RUNNING_FOR_PREVIEWS`. ## Summary - Add `XcodePreviews`, a debug-only namespace that reports whether the process is rendering previews - Suppress the check when either the configuration flag is set **or** the process is rendering previews, at two places: `Store` state reads, and the render pass that `workflowPreview` drives - No public API change: `Runtime.Configuration.suppressPerceptionCheckingWhenUsingObservation` keeps the exact semantics #393 documented, and remains the only way to suppress outside of previews - Both the detection and its consumers are behind `#if DEBUG`, so release builds are unaffected ### Why key on the process rather than on `Store.preview` The narrower fix — tag stores built by `Store.preview` and suppress for those — only covers half the cases. `Store.preview` and `ObservableScreen.observableScreenPreview` produce a static store, and tagging works there. But `workflowPreview` hosts a **real** workflow in a `WorkflowHostingController`, so its stores come out of the ordinary render path through `make(model:)` and are indistinguishable from an app's. Any wrapper built on `workflowPreview` inherits that, and there are such wrappers in the wild. Keying on the process covers both, and avoids threading a flag through `scope(...)`'s child-store construction and the `_StoreCollection` paths. I also considered defaulting `suppressPerceptionCheckingWhenUsingObservation` from the environment instead. That puts a UI heuristic in the core `Workflow` module and needs `#if DEBUG` around a public property's default value. Keeping it in `WorkflowSwiftUI` means the read site is already inside `#if DEBUG && canImport(Observation)`. ### Why `Store` alone isn't enough Suppressing at `Store` covers reads a *view* makes. It does not cover reads a **workflow makes of its own state inside `render`**, which reach the state accessor directly and never touch a `Store` — so nothing gated on the flag or on the process could reach them. Any workflow that reads its own observable state while rendering trips this, which is most of them; the `ObservableComposition` sample does it in two places. Those reads are misreported for the same underlying reason: Perception decides whether it is looking at a SwiftUI view body by walking the call stack for AttributeGraph frames. `PreviewView`'s representable callbacks drive a render pass synchronously and are themselves called by SwiftUI, so those frames are on the stack and every observable read the pass makes trips the check. This is unique to previews. The same workflow running in an app renders off a runtime update rather than a SwiftUI one, so no AttributeGraph frame is present and the check correctly stays quiet — which is why the warnings appear in the canvas and nowhere else. Wrapping both callbacks fixes it. The predicate deciding *when* suppression applies is lifted out of `Store` into a module-level funnel at the same time, so that rule lives in one place rather than being duplicated at each site that needs it. ### On testing `XcodePreviews.isRunning` reads the process environment once, which a test cannot vary, so the lookup is factored into a pure function that is tested directly. The composed predicate is a one-line `||` whose other operand is already covered by `test_perceptionRuntimeWarningsCanBeSuppressedWhenUsingObservation`. `test_isRunning_isFalseInTheTestProcess` exists to protect the *negative* test. `test_perceptionRuntimeWarningsWhenUsingObservation` asserts that the warning fires when unsuppressed, and its assertion is the absence of a Perception failure — so if the test process ever read as a preview, that test would silently become a vacuous pass. No test accompanies the `PreviewView` wrap. `UIViewControllerRepresentableContext` cannot be constructed outside of SwiftUI, so the callbacks cannot be driven from a test, and the stack-walk heuristic they work around cannot be reproduced without a real SwiftUI update. ## Test plan - [x] `tuist test --path Samples UnitTests` on iPad Air (5th generation), iOS 26.5 — the 4 new `XcodePreviewsTests` pass - [x] `test_perceptionRuntimeWarningsWhenUsingObservation` still passes, i.e. the warning still fires when neither the flag nor a preview applies - [x] `test_perceptionRuntimeWarningsCanBeSuppressedWhenUsingObservation` still passes - [x] `swift build --target WorkflowSwiftUI` clean; `swiftformat --lint` clean - [x] Reproduced in a live canvas before the fix: `MultiCounterView_Previews` reports `\State.<computed … (Int)>` on every canvas update, which is `CounterWorkflow.State.count` read from `CounterWorkflow.render` and `MultiCounterWorkflow.render` - [x] Confirmed in a live canvas after the fix — silent on canvas updates, and silent while driving the counters ## Checklist - [x] Unit Tests - [x] UI Tests (not applicable) - [x] Snapshot Tests (not applicable) - [x] I have made corresponding changes to the documentation
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PRs #389 and #390 automatically suppressed Perception runtime warnings on platforms using native Observation. This restores the warnings by default while preserving an opt-in suppression path.
Summary
Runtime.Configuration.suppressPerceptionCheckingWhenUsingObservation, defaulting tofalseTest plan
StoreTestson iOS 17.5 — 25 passedStoreTestson iOS 16.2 — passed with 8 expected skipsChecklist